# Component resolution, aggregation and other utility functions.
# Provide the content of `rust-bin`.
{
  lib,
  pkgs,
  manifests,
  nightly,
}:
let
  inherit (builtins)
    compareVersions
    fromTOML
    match
    readFile
    tryEval
    ;

  inherit (lib)
    any
    attrNames
    attrValues
    concatStringsSep
    elem
    elemAt
    filter
    flatten
    foldl'
    hasPrefix
    head
    isString
    length
    listToAttrs
    makeOverridable
    mapAttrs
    mapAttrsToList
    optional
    optionalAttrs
    replaceStrings
    substring
    trace
    unique
    ;

  inherit (pkgs) stdenv callPackage fetchurl;

  # Remove keys from attrsets whose value is null.
  removeNulls = set: removeAttrs set (filter (name: set.${name} == null) (attrNames set));

  toRustTarget = platform: platform.rust.rustcTarget;

  # The platform where `rustc` is running.
  rustHostPlatform = toRustTarget stdenv.hostPlatform;
  # The platform of binary which `rustc` produces.
  rustTargetPlatform = toRustTarget stdenv.targetPlatform;

  mkComponentSet = callPackage ./mk-component-set.nix {
    inherit removeNulls toRustTarget;
  };

  mkAggregated = callPackage ./mk-aggregated.nix { };

  # Manifest selector.
  selectManifest =
    {
      channel,
      date ? null,
    }:
    let
      assertWith =
        cond: msg: body:
        if cond then body else throw msg;

      # https://rust-lang.github.io/rustup/concepts/toolchains.html#toolchain-specification
      # <channel> = stable|beta|nightly|<major.minor>|<major.minor.patch>

      asVersion = match "[0-9]+\\.[0-9]+(\\.[0-9]+)?" channel;
      asNightlyDate =
        let
          m = match "nightly-([0-9]+-[0-9]+-[0-9]+)" channel;
        in
        if m == null then null else elemAt m 0;
      asBetaDate =
        let
          m = match "beta-([0-9]+-[0-9]+-[0-9]+)" channel;
        in
        if m == null then null else elemAt m 0;

      maxWith = zero: f: foldl' (lhs: rhs: if lhs == zero || f lhs rhs < 0 then rhs else lhs) zero;

      latestStableWithMajorMinor = maxWith "" compareVersions (
        filter (hasPrefix (channel + ".")) (attrNames manifests.stable)
      );

    in
    # "stable"
    if channel == "stable" then
      assertWith (
        date == null
      ) "Stable version with specific date is not supported" manifests.stable.latest
    # "nightly"
    else if channel == "nightly" then
      manifests.nightly.${if date != null then date else "latest"}
        or (throw "Nightly ${date} is not available")
    # "beta"
    else if channel == "beta" then
      manifests.beta.${if date != null then date else "latest"} or (throw "Beta ${date} is not available")
    # "1.49.0" or "1.49"
    else if asVersion != null then
      assertWith (date == null) "Stable version with specific date is not supported" (
        # "1.49"
        if asVersion == [ null ] then
          manifests.stable.${latestStableWithMajorMinor} or (throw "No stable ${channel}.* is available")
        # "1.49.0"
        else
          manifests.stable.${channel} or (throw "Stable ${channel} is not available")
      )
    # "beta-2021-01-01"
    else if asBetaDate != null then
      assertWith (date == null) "Cannot specify date in both `channel` and `date`"
        manifests.beta.${asBetaDate} or (throw "Beta ${asBetaDate} is not available")
    # "nightly-2021-01-01"
    else if asNightlyDate != null then
      assertWith (date == null) "Cannot specify date in both `channel` and `date`"
        manifests.nightly.${asNightlyDate} or (throw "Nightly ${asNightlyDate} is not available")
    # Otherwise
    else
      throw "Unknown channel: ${channel}";

  # Select a toolchain and aggregate components by rustup's `rust-toolchain` file format.
  # See: https://rust-lang.github.io/rustup/concepts/profiles.html
  # Or see source: https://github.com/rust-lang/rustup/blob/84974df1387812269c7b29fa5f3bb1c6480a6500/doc/src/overrides.md#the-toolchain-file
  fromRustupToolchain =
    {
      path ? null,
      channel ? null,
      profile ? null,
      components ? [ ],
      targets ? [ ],
    }:
    if path != null then
      throw "`path` is not supported, please directly add it to your PATH instead"
    else if channel == null then
      throw "`channel` is required"
    else
      let
        toolchain = toolchainFromManifest (selectManifest {
          inherit channel;
        });
        profile' = if profile == null then "default" else profile;
        pkg =
          if toolchain._profiles != { } then
            toolchain._profiles.${profile'} or (throw ''
              Rust ${toolchain._version} doesn't have profile `${profile'}`.
              Available profiles are: ${concatStringsSep ", " (attrNames toolchain._profiles)}
            '')
          # Fallback to package `rust` when profiles are not supported and not specified.
          else if profile == null then
            toolchain.rust
          else
            throw "Cannot select profile `${profile'}` since rust ${toolchain._version} is too early to support profiles";
      in
      pkg.override {
        extensions = components;
        inherit targets;
      };

  # Same as `fromRustupToolchain` but read from a `rust-toolchain` file (legacy one-line string or in TOML).
  fromRustupToolchainFile =
    path:
    let
      content = readFile path;
      legacy = match "([^\r\n]+)\r?\n?" content;
    in
    if legacy != null then
      fromRustupToolchain { channel = head legacy; }
    else
      fromRustupToolchain (fromTOML content).toolchain;

  mkComponentSrc =
    { url, sha256 }:
    let
      url' = replaceStrings [ " " ] [ "%20" ] url; # This is required or download will fail.
      # Filter names like `llvm-tools-1.34.2 (6c2484dc3 2019-05-13)-aarch64-unknown-linux-gnu.tar.xz`
      matchParenPart = match ".*/([^ /]*) [(][^)]*[)](.*)" url;
    in
    # NB: This seems verbose, but we do not know the default special-cased `name` is.
    # It used to be "", but changed to `null` later. We also do not know the exact
    # revision of nixpkgs we are currently using.
    # See: <http://github.com/NixOS/nixpkgs/commit/1ec0227cc062251c36140ef1e7c37b1cd1b370f1>
    if matchParenPart != null then
      fetchurl {
        name = (elemAt matchParenPart 0) + (elemAt matchParenPart 1);
        inherit sha256;
        url = url';
      }
    else
      fetchurl {
        inherit sha256;
        url = url';
      };

  # Resolve final components to install from mozilla-overlay style `extensions`, `targets` and `targetExtensions`.
  #
  # `componentSet` has a layout of `componentSet.<name>.<rust-target> : Derivation`.
  # `targetComponentsList` is a list of all component names for target platforms.
  # `name` is only used for error message.
  #
  # Returns a list of component derivations, or throw if failed.
  resolveComponents =
    {
      name,
      componentSet,
      allComponentSet,
      allPlatformSet,
      targetComponentsList,
      profileComponents,
      extensions,
      targets,
      targetExtensions,
    }:
    let
      # Components for target platform like `rust-std`.
      collectTargetComponents =
        allowMissing: name:
        let
          targetSelected = flatten (map (tgt: componentSet.${tgt}.${name} or [ ]) targets);
        in
        if !allowMissing -> targetSelected != [ ] then
          targetSelected
        else
          "Component `${name}` doesn't support any of targets: ${concatStringsSep ", " targets}";

      collectComponents =
        allowMissing: name:
        if elem name targetComponentsList then
          collectTargetComponents allowMissing name
        else
          # Components for host platform like `rustc`.
          componentSet.${rustHostPlatform}.${name}
            or (if allowMissing then [ ] else "Host component `${name}` doesn't support `${rustHostPlatform}`");

      # Profile components can be skipped silently when missing.
      # Eg. `rust-mingw` on non-Windows platforms, or `rust-docs` on non-tier1 platforms.
      result =
        flatten (map (collectComponents true) profileComponents)
        ++ flatten (map (collectComponents false) extensions)
        ++ flatten (map (collectTargetComponents false) targetExtensions);

      isTargetUnused =
        target:
        !any (name: componentSet ? ${target}.${name})
          # FIXME: Get rid of the legacy component `rust`.
          (
            filter (name: name == "rust" || elem name targetComponentsList) (profileComponents ++ extensions)
            ++ targetExtensions
          );

      # Fail-fast for typo in `targets`, `extensions`, `targetExtensions`.
      fastErrors = flatten (
        map (
          tgt:
          optional (
            !(allPlatformSet ? ${tgt})
          ) "Unknown target `${tgt}`, typo or not supported by this version?"
        ) targets
        ++ map (
          name:
          optional (
            !(allComponentSet ? ${name})
          ) "Unknown component `${name}`, typo or not support by this version?"
        ) (profileComponents ++ extensions ++ targetExtensions)
      );

      errors =
        if fastErrors != [ ] then
          fastErrors
        else
          filter isString result
          ++ map (tgt: "Target `${tgt}` is not supported by any components or extensions") (
            filter isTargetUnused targets
          );

      notes = [
        "note: profile components: ${toString profileComponents}"
      ]
      ++ optional (targets != [ ]) "note: selected targets: ${toString targets}"
      ++ optional (extensions != [ ]) "note: selected extensions: ${toString extensions}"
      ++ optional (
        targetExtensions != [ ]
      ) "note: selected targetExtensions: ${toString targetExtensions}"
      ++ flatten (
        map (
          platform:
          optional (componentSet ? ${platform})
            "note: components available for ${platform}: ${toString (attrNames componentSet.${platform})}"
        ) (unique ([ rustHostPlatform ] ++ targets))
      )
      ++ [
        ''
          note: check here to see all targets and which components are available on each targets:
                https://rust-lang.github.io/rustup-components-history
        ''
      ];

    in
    if errors == [ ] then
      result
    else
      throw ''
        Component resolution failed for ${name}
        ${concatStringsSep "\n" (errors ++ notes)}
      '';

  # Generate the toolchain set from a parsed manifest.
  #
  # Manifest files are organized as follow:
  # { date = "2017-03-03";
  #   pkg.cargo.version= "0.18.0-nightly (5db6d64 2017-03-03)";
  #   pkg.cargo.target.x86_64-unknown-linux-gnu = {
  #     available = true;
  #     hash = "abce..."; # sha256
  #     url = "https://static.rust-lang.org/dist/....tar.gz";
  #     xz_hash = "abce..."; # sha256
  #     xz_url = "https://static.rust-lang.org/dist/....tar.xz";
  #   };
  # }
  #
  # The packages available usually are:
  #   cargo, rust-analysis, rust-docs, rust-src, rust-std, rustc, and
  #   rust, which aggregates them in one package.
  #
  # For each package the following options are available:
  #   extensions        - The extensions that should be installed for the package.
  #                       For example, install the package rust and add the extension rust-src.
  #   targets           - The package will always be installed for the host system, but with this option
  #                       extra targets can be specified, e.g. "mips-unknown-linux-musl". The target
  #                       will only apply to components of the package that support being installed for
  #                       a different architecture. For example, the rust package will install rust-std
  #                       for the host system and the targets.
  #   targetExtensions  - If you want to force extensions to be installed for the given targets, this is your option.
  #                       All extensions in this list will be installed for the target architectures.
  #                       *Attention* If you want to install an extension like rust-src, that has no fixed architecture (arch *),
  #                       you will need to specify this extension in the extensions options or it will not be installed!
  toolchainFromManifest =
    manifest:
    let
      # platform -> true
      # For fail-fast test.
      allPlatformSet = listToAttrs (
        flatten (
          mapAttrsToList (
            compName:
            { target, ... }:
            map (platform: {
              name = platform;
              value = true;
            }) (attrNames target)
          ) manifest.pkg
        )
      );

      # componentName -> true
      # May also contains unavailable components. Just for fail-fast test.
      allComponentSet = mapAttrs (compName: _: true) (manifest.pkg // manifest.renames);

      # componentSet.x86_64-unknown-linux-gnu.cargo = <derivation>;
      componentSet = mapAttrs (
        platform: _:
        mkComponentSet {
          inherit (manifest) version renames;
          inherit platform;
          srcs = removeNulls (
            mapAttrs (
              compName:
              { target, ... }:
              let
                content = target.${platform} or target."*" or null;
              in
              if content == null then
                null
              else
                mkComponentSrc {
                  url = content.xz_url;
                  sha256 = content.xz_hash;
                }
            ) manifest.pkg
          );
        }
      ) allPlatformSet;

      mkProfile =
        name: profileComponents:
        makeOverridable
          (
            {
              extensions,
              targets,
              targetExtensions,
            }:
            mkAggregated {
              pname = "rust-${name}";
              inherit (manifest) version date;
              availableComponents = componentSet.${rustHostPlatform};
              selectedComponents = resolveComponents {
                name = "rust-${name}-${manifest.version}";
                inherit
                  allPlatformSet
                  allComponentSet
                  componentSet
                  profileComponents
                  targetExtensions
                  ;
                inherit (manifest) targetComponentsList;
                extensions = extensions;
                targets = unique (
                  [
                    rustHostPlatform # Build script requires host std.
                    rustTargetPlatform
                  ]
                  ++ targets
                );
              };
            }
          )
          {
            extensions = [ ];
            targets = [ ];
            targetExtensions = [ ];
          };

      profiles = mapAttrs mkProfile manifest.profiles;

      result =
        # Individual components.
        componentSet.${rustHostPlatform}
        //
          # Profiles.
          profiles
        // {
          # Legacy support for special pre-aggregated package.
          # It has more components than `default` profile but less than `complete` profile.
          rust =
            let
              pkg = mkProfile "legacy" [ "rust" ];
            in
            if profiles != { } then
              trace ''
                Rust ${manifest.version}:
                Pre-aggregated package `rust` is not encouraged for stable channel since it contains almost all and uncertain components.
                Consider use `default` profile like `rust-bin.stable.latest.default` and override it with extensions you need.
                See README for more information.
              '' pkg
            else
              pkg;
        };

    in
    # If the platform is not supported for the current version, return nothing here,
    # so others can easily check it by `toolchain ? default`.
    optionalAttrs (componentSet ? ${rustHostPlatform}) result
    // {
      # Internal use.
      _components = componentSet;
      _profiles = profiles;
      _version = manifest.version;
      _manifest = manifest;
    };

  # From a git revision of rustc.
  # This does the same thing as crate `rustup-toolchain-install-master`.
  # But you need to manually provide component hashes.
  fromRustcRev =
    {
      # Package name of the derivation.
      pname ? "rust-custom",
      # Git revision of rustc.
      rev,
      # Version of the built package.
      version ? substring 0 7 rev,
      # Attrset with component name as key and its SRI hash as value.
      components,
      # Rust target to download.
      target ? rustTargetPlatform,
    }:
    let
      hashToSrc =
        compName: hash:
        fetchurl {
          url =
            if compName == "rust-src" then
              "https://ci-artifacts.rust-lang.org/rustc-builds/${rev}/${compName}-nightly.tar.xz"
            else
              "https://ci-artifacts.rust-lang.org/rustc-builds/${rev}/${compName}-nightly-${target}.tar.xz";
          inherit hash;
        };
      components' = mkComponentSet {
        inherit version;
        platform = target;
        srcs = mapAttrs hashToSrc components;
        # We cannot know aliases in this case.
        renames = { };
      };
    in
    mkAggregated {
      inherit pname version;
      date = null;
      selectedComponents = attrValues components';
    };

  # Select latest nightly toolchain which makes selected profile builds.
  # Some components are missing in some nightly releases.
  # Usage:
  # `selectLatestNightlyWith (toolchain: toolchain.default.override { extensions = ["llvm-tools-preview"]; })`
  selectLatestNightlyWith =
    selector:
    let
      nightlyDates = attrNames (removeAttrs nightly [ "latest" ]);
      dateLength = length nightlyDates;
      go =
        idx:
        let
          ret = selector (nightly.${elemAt nightlyDates idx});
        in
        if idx == 0 then
          ret
        else if dateLength - idx >= 256 then
          trace "Failed to select nightly version after 100 tries" ret
        else if ret != null && (tryEval ret.drvPath).success then
          ret
        else
          go (idx - 1);
    in
    go (length nightlyDates - 1);

in
# For each channel:
#   rust-bin.stable.latest.{minimal,default,complete} # Profiles.
#   rust-bin.stable.latest.rust   # Pre-aggregate from upstream.
#   rust-bin.stable.latest.cargo  # Components...
#   rust-bin.stable.latest.rustc
#   rust-bin.stable.latest.rust-docs
#   ...
#
# For a specific version of stable:
#   rust-bin.stable."1.47.0".default
#
# For a specific date of beta:
#   rust-bin.beta."2021-01-01".default
#
# For a specific date of nightly:
#   rust-bin.nightly."2020-01-01".default
mapAttrs (channel: mapAttrs (version: toolchainFromManifest)) manifests
// {
  inherit fromRustupToolchain fromRustupToolchainFile;
  inherit selectLatestNightlyWith;
  inherit fromRustcRev;

  _internal = {
    inherit toolchainFromManifest;
    inherit selectManifest;
  };
}
